fix(driver-sql): schema drift reports a single-value JSON-class field on a stale text column - #16073
Conversation
… on a stale text column `createColumn` gives a json column to every JSON-class TYPE and `isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, but `diffManagedTable`'s base-type branch asked only `field.multiple === true`. A single-value `file` / `location` / `record` / `vector` / `json` field on a `varchar`/`text` column was therefore written as JSON by the writer and invisible to the differ — and the additive sync never revisits a column, so the divergence was permanent and silent. Measured on the previous tree: all fifteen JSON-class types the spec declares produced zero findings on that column under `postgres` and `mysql`. The detector now reads the writer's predicate. The remedy splits by VALUE SHAPE: `os migrate multi-value-columns` wraps each value in a one-element JSON array, so it stays offered to array-valued fields — whose message is unchanged character for character, which is what lets `planStaleColumnTargets` keep recovering the dialect from it — and is withheld from single-value ones, whose message carries neither the command nor its statement and is therefore refused with `remedy_not_recognized` rather than running array SQL over scalar rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…me prose `check:doc-authoring` refuses a `#NNNN` in a string an operator reads — it resolves to nothing without the tracker (maintainer ruling 2026-08-12). The anchor stays in the `//` comment beside the emission and in git history; the message now carries the CAUSE in words instead, which is what the reader actually needs. The pin asserts that wording rather than the id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…hema-drift-single-value-json-column
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 47738a4b4853f145ab338e2fb62ff0c7a6206062 && git checkout 47738a4b4853f145ab338e2fb62ff0c7a6206062
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31403453dda6de58d2bbed91a1448a65e2d0a1a3 78495690974af0bf6763421241a541322fc51aa5 && git checkout -B drift-repro 31403453dda6de58d2bbed91a1448a65e2d0a1a3 && git merge --no-ff 78495690974af0bf6763421241a541322fc51aa5
node scripts/docs-audit/affected-docs.mjs --json 31403453dda6de58d2bbed91a1448a65e2d0a1a3 |
Fixes #15771
The defect was a FORK, not a missing rule
The driver decides a column's type with one predicate and reported drift about it with another:
So a SINGLE-value JSON-class field —
file,image,avatar,video,audio,location,address,composite,record,vector,json, and the option families — sitting on avarchar/textcolumn was written as JSON by the writer and did not exist to the differ.syncSchemais additive and never migrates a column's type, so the divergence was permanent, and nothing else reports it, so it was silent.This PR deletes the fork: the detector now reads the writer's own predicate. No new criterion was invented.
Reproduction, before and after
One
diffManagedTablecall per type,character varying(2048)column, both enforcing dialects:The
multiple: truerow is the firing control in the same run: the differ was working, and this whole population was invisible to it.The message SPLITS, because the shipped remedy is wrong for half of it
os migrate multi-value-columnsrepairs a stale column by WRAPPING each stored value in a one-element JSON array (json_build_array(col)on Postgres,JSON_ARRAY(col)on MySQL). That is right for a field whose value is an array and wrong for one whose value is a scalar or an object. Measured with the command's own planner, on a single-value finding carrying the multi-value message:And measured on an in-memory SQLite cell, what that would do to the value:
So both populations are REPORTED and only one is offered the remedy:
multiple: true, plus the inherently-multi option types, whose value is a list with or without the flag) keeps its message character for character. That is a contract, not a style choice:planStaleColumnTargetsrecovers the dialect by testingmessage.includes(manualJsonConversionSql(d, table, column)), so a reworded message makes the command refuse every finding. Proven againstorigin/mainitself while writing the change — the whole entry, message included, compared equal forlookup,stringandfilewithmultiple: true, on both dialects.remedy_not_recognized— its designed branch for a message it cannot read — instead of running array SQL over scalar rows. Pinned from both sides.⛔ No new remedy DDL was invented.
manualJsonConversionSql's two existing forms are EXECUTED against live servers by the suite, and two of their arms were corrected only by running an earlier version against a live database; there is no live Postgres or MySQL cell in this container, so a third unmeasured statement is not something this PR ships. The remedy split is filed as #16072 with the three options and what each costs.SQLite: the reverse control, re-measured
On SQLite the harm is not reachable, and that is a measurement rather than a scoping convenience. On an in-memory cell, a single-value
filefield over the stalevarchar(2048)column and over the driver's own column:The read path parses a textual column regardless of what it calls itself, so there is nothing to corrupt and nothing to report. The pin keeps that leg as its reverse control: without it, every positive assertion above is equally satisfied by a detector that always reports.
What the consequence chain is, and is NOT
The card's downstream chain is REASONED FROM CODE and is not upgraded here:
formatInputJSON.stringifys everyisJsonFieldvalue unconditionally, and the read-sideJSON.parselives only inside theisSqlitearm offormatOutput, so on Postgres/MySQL a varchar column returns the quoted text verbatim,isFileIdTokenrefuses it, the read resolver never expands it and the ownership hooks never claim it. There is no live Postgres or MySQL cell in this container, so that chain is stated exactly as the card stated it. What IS measured here is the stored form (SQLite, byte-for-byte), the detector's before/after verdicts, and the planner's behaviour.Also corrected by the same predicate: a single-value JSON-class field declaring a
maxLengthover a widervarcharcolumn used to be reported asnarrow_varcharat categorydestructive, invitingos migrate apply --allow-destructiveto rewrite the column to a narrower varchar — the opposite of the repair it needs. It is now reported once, as the base-type divergence. Pinned.Scope
⛔
packages/cli/src/commands/generate.tsis untouched: theVARCHAR(2048)-vs-json-column divergence in the generated migration is #15041's subject and that file's own docblock already acknowledges it. This change is only about the detector, and the blind spot it closes applies to every single-value member of the writer's set whichever way #15041 goes. Out of scope here, still open: #15041, and #16072 for the remedy split.Published surface — the
dist/index.d.tsablationBuilt at head, swapped
schema-drift.tsback toorigin/main, rebuilt, diffed the builtdist/index.d.ts, restored byte-exact:Both limbs of the surface question, stated separately:
dist/index.d.tsis byte-identical between the two builds.JSON_COLUMN_FIELD_TYPESis module-exported so this package's own suites can pin it and is deliberately NOT added toindex.ts— the same callUNBOUNDED_TEXT_FIELD_TYPESandMULTI_VALUE_COLUMN_REMEDY_COMMANDmake.os migrate multi-value-columnsnow meets a population it never met — is handled by routing that population into the command's refusal branch, so nothing new is executed there either.Tests
New:
packages/drivers/driver-sql/src/schema-drift.json-column-parity.test.tsholds the differ's JSON-class predicate equal to the writer'sisJsonFieldover everyFieldTypethe spec declares, in both directions, and compares the two halves where they actually meet — throughdiffManagedTable's output rather than between two constants. Extended:schema-drift.base-type-mismatch.test.ts(the single-value cases on both enforcing dialects, the SQLite non-finding as the reverse control, the whole-vocabulary sweep, the message split in both directions, the byte-identity of the array message, and thenarrow_varcharregression) andpackages/cli/src/commands/migrate/multi-value-columns.dialect-probe.test.ts(the refusal, with an array-valued target as the contrast in the same run).Verified at
784956909:Generated by Claude Code